Skip to content

sommerfeld: fix subdivision cap, interpolation cache, and efld radius - #130

Open
KJ7LNW wants to merge 3 commits into
tmolteno:masterfrom
KJ7LNW:fix-rom2-sommerfeld-efld-norm
Open

sommerfeld: fix subdivision cap, interpolation cache, and efld radius#130
KJ7LNW wants to merge 3 commits into
tmolteno:masterfrom
KJ7LNW:fix-rom2-sommerfeld-efld-norm

Conversation

@KJ7LNW

@KJ7LNW KJ7LNW commented Aug 12, 2026

Copy link
Copy Markdown

Description

Over a GN 2 Sommerfeld ground, nec2++ reports feed impedances that are
wrong by tens to hundreds of ohms, and on low antennas the feed resistance
comes out negative — a passive structure appearing to generate power. A
40 m-band dipole (37-3e) reports R = -60.3 Ω where the NEC-2 Fortran reports
+130.194 Ω.

Three independent defects in the Sommerfeld path produce this. Each is
corrected in its own commit and each was measured in isolation with the other
two applied, because their effects are not additive: the thin-wire radius
correction moves 37-3e by 2.9 Ω while the interpolation cache is still
defective, and by 0.03 Ω once it is fixed.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

C-lineage scope

One of the three defects is not specific to nec2++. The NEC-2 Fortran
declares the Romberg subdivision limit as a routine-local constant:

      DATA NM,NTS,NX,N/65536,4,1,9/,RX/1.D-4/

nec2dx.f:7976 and nec2dxs.f:9068 both carry it. The original C translation
substituted the structure's segment-plus-patch count in its place, and every
C-lineage port inherited that substitution:

implementation lineage subdivision bound
nec2dx.f, nec2dxs.f Fortran NM = 65536, correct
nec2c original C port segment-plus-patch count
xnec2c from nec2c segment-plus-patch count
nec2++ from nec2c segment-plus-patch count

The same fix has been applied to nec2c and xnec2c; only nec2++ reaches
the bound on published models, because the other two defects here drive it
there. The remaining two defects are nec2++-only.

Implementation Details

src/nec_context.cpprom2 subdivision cap (b765112)

Both rom2 copies read m_geometry->n_plus_m() at the two sites the reference
reads NM: the minimum-interval epsilon and the step-halving bound. The
accuracy ceiling of the ground integration therefore scaled with model size
rather than with the sharpness of the integrand, so the smallest models over
ground received the coarsest integration — exactly where per-segment ground
interaction is strongest.

When the 5-point Romberg test fails at the finest permitted step, the warning
path falls through to loop10, which accumulates t20 — the extrapolation
that just failed its own error test. The corrected run terminates on the error
test RX = 1e-4 instead of on an exhausted counter.

Both the live copy under #if 1 and the disabled twin under #else are
patched in one commit, so the toggle cannot reintroduce a divergent bound.

src/c_ggrid.cpp — interpolation cache guard (d0ee6a6)

c_ggrid::interpolate caches the bivariate coefficients of the 4-by-4 grid
region around the previous request. Its opening guard had the two branches
exchanged relative to SUBROUTINE INTRP: a point at or above the region
origin recomputed unconditionally, while a point below the origin — the case
the reference forces to rebuild, since it carries no valid index — reused
whatever coefficients the previous call left behind whenever the stale indices
happened to satisfy the proximity test.

The routine therefore returned different values for the same (x, y)
depending on the order points were requested. Restored to the reference form:
a jump flag for the below-origin case, indices computed only in the other
branch, and one disjunction combining jump with the four index tests.

src/nec_context.cpp — thin-wire displacement radius (a89a6f9)

efld displaces the observation point off the wire axis by the wire radius,
dividing by the squared radial distance. The Sommerfeld section computed
norm() where math_util.h offers norm2() for the squared sum, and the next
statement takes a square root of it — so the displacement was ai/sqrt(|rho|)
instead of ai/|rho|. The comment on the defective line stated the correct
expression.

The overloads invite this: for three scalars norm() is the square root of the
sum of squares, while for a complex argument norm() is the squared modulus.
The other twelve call sites in the file were checked against that distinction
and each is correct as written.

Measured proof

Feed resistance in ohms, nec2++ -w, each defect toggled alone with the other
two corrected. Reference is nec2dxs, which nec2dx reproduces exactly:

defect witness defective fixed reference
subdivision cap gn2_beverage_low 604.74 604.87 604.924
interpolation cache 37-3e -60.261 130.19 130.194
interpolation cache gn2_beverage_low -528.51 604.87 604.924
thin-wire radius 37-3e 130.22 130.19 130.194

Order dependence of the cache defect, isolated from every other difference by
driving nec2++ interpolation over a grid nec2c generated. 20301 points
swept ascending, then the identical points descending:

pass differing records worst relative
forward 0 of 20301 0
reverse 3657 of 20301 1.967

Across the 16 GN 2 decks of a five-implementation validation corpus
(nec2c, xnec2c, nec2++, nec2dx, nec2dxs), failing comparison points
on nec2++ edges fall from 489 to 208 with all three fixes applied.

Reproduction

A one-wavelength Beverage isolates the subdivision bound from every other
property of a model: eleven segments give a segment-plus-patch count of 11,
while the integrand at 0.0015 wavelengths height needs ns = 32, so the
substituted bound blocks every doubling past 16.

Before: 22 ROM2 -- STEP SIZE LIMITED lines on stderr; feed R = 604.74 Ω.
After: no warnings; feed R = 604.87 Ω against a reference of 604.924 Ω.

CM Low Beverage receiving antenna: 170 m of #14 wire 0.25 m over average
CM earth at 1.8 MHz, fed at one end and terminated at the far end into
CM 450 ohms. One wavelength long, eleven segments of 0.093 wl each, the
CM wire at 0.0015 wl.
CE
GW 1 11 0 0 0.25 170 0 0.25 0.0008128
GE 1
GN 2 0 0 0 13.0 0.005
LD 4 1 11 11 450 0
FR 0 1 0 0 1.8 0
EX 0 1 1 0 1 0
RP 0 10 37 1000 0 0 10 10
EN

The same antenna raised to 10 m settles at ns = 2 and its output is
byte-identical before and after the subdivision fix, so height alone drives
the difference. It is not inert to the other two defects: with the cache guard
reverted it moves 48 Ω in reactance, which is what makes it a control rather
than a dead deck.

Before: GW 1 11 0 0 10 170 0 10 0.0008128, feed 3250.9 + j615.17 Ω with the
cache guard defective.
After: 3264.5 + j567.17 Ω, reference 3262.85 + j567.229 Ω.

Testing

  • Cross-engine validation against nec2dx and nec2dxs on 16 GN 2 decks
  • Feed-point agreement to printed precision on 37-3e and 11-3a
  • Order-dependence sweep over 20301 interpolation points, forward and
    reverse, on a grid generated by an independent implementation
  • Upstream nec2++ test suite not run in this environment

The 37-3e, 11-3a and 20-40m_vert_sommerfeld_cliff decks are published
models; the Beverage pair was authored for this defect and is the minimal
witness.

Eric Wheeler added 3 commits August 11, 2026 17:49
…size

nec_context::rom2 derived its minimum-interval epsilon and step-halving bound
from the structure's segment-plus-patch count instead of the routine-local
NM=65536 constant NEC-2 declares in SUBROUTINE ROM2. Small models over ground
therefore received the coarsest Sommerfeld integration, exactly where
per-segment ground interaction is strongest, and could exhaust the bound
before the relative-error test converged, accumulating an unconverged
Richardson extrapolation into the field sum.

- declare nma = 65536 in both rom2 copies (#if 1 and #else), alongside the
  reference quartet each already carries
- consume nma in place of m_geometry->n_plus_m() at the epsilon derivation
  and both step-halving bound checks in each copy, restoring the sixteen
  halvings NEC-2 permits regardless of geometry size

- No control flow changes; hfk, intx, and rom1 already used the correct
  constant and are unaffected.

Signed-off-by: Eric Wheeler <necpp@z.ewheeler.org>
c_ggrid::interpolate cached the 4x4 grid region coefficients around the
previous request and reused them when the next point fell in the same
region, but its opening guard had the two branches exchanged relative to
the NEC-2 reference. A point at or above the cached origin recomputed
unconditionally, while a point below the origin reused stale coefficients
whenever the leftover indices happened to satisfy the proximity test, so
interpolation returned different values for the same argument pair
depending on request order. This perturbed every Sommerfeld
ground-reflected field the interpolation grid supplies to the impedance
matrix, flipping the feed resistance sign at low heights over ground
where it is a near-cancellation of large reactive terms.

- introduce a jump flag set only when the point lies below the cached
  region origin, matching SUBROUTINE INTRP: indices are computed solely
  in the complementary branch
- combine jump with the four index proximity tests into one disjunction
  that decides recomputation, replacing the inverted two-branch logic

Signed-off-by: Eric Wheeler <necpp@z.ewheeler.org>
nec_context::efld displaces the observation point off the wire axis by the
wire radius when the field point lies near the source segment during
Sommerfeld ground evaluation. The displacement divides by the squared radial
distance, but the code computed norm (a square root) instead of norm2 (the
squared sum), taking the root twice and placing the field point at the wrong
radius on every thin-wire ground evaluation.

- replace norm(rhox, rhoy, rhoz) with norm2(rhox, rhoy, rhoz) in efld to match
  the squared-sum quantity the reference nec2c and nec2dx.f divide by, fixing
  the thin-wire radial displacement over Sommerfeld ground

Signed-off-by: Eric Wheeler <necpp@z.ewheeler.org>
tmolteno added a commit that referenced this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant